polvara.me

Branded types in TypeScript

Dec 1, 2023

Branded types are a relatively unknown feature of TypeScript but can help make a codebase safer. As with all types, they don’t replace testing or QA but augment them, improving the developer experience.

We can see why they are useful with a simple example. See if you can spot the bug in the snippet below.

function purchaseFlight(userId: string, flightId: string) {
  // Pretend we’re buying a flight ticket
}

const userId: string = "U123";
const flightId: string = "F456";

purchaseFlight(flightId, userId);

The error is right at the bottom. We’re calling purchaseFlight with its arguments reversed: the user ID becomes the flight ID and vice versa. Again, you should have tests covering this scenario, but it would be great if TypeScript helped us while writing this code.

The issue is that userId and flightId are of type string, so there’s no way to distinguish them at the type level. Let’s try by introducing type aliases.

type UserId = string;
type FlightId = string;

function purchaseFlight(userId: UserId, flightId: FlightId) {
  // Pretend we’re buying a flight ticket
}

const userId: UserId = "U123";
const flightId: FlightId = "F456";

purchaseFlight(flightId, userId);

This version would make sense in a language like Java or C++ that supports a nominal type system but not in TypeScript. This code and the one above are equivalent.

Here’s where branded types come into play. With a branded type, we can simulate nominal types. See the updated version of our example.

type UserId = Brand<string, "UserId">;
type FlightId = Brand<string, "FlightId">;

function purchaseFlight(userId: UserId, flightId: FlightId) {
  // Pretend we’re buying a flight ticket
}

const userId = "U123" as UserId;
const flightId = "F456" as FlightId;

// @ts-expect-error
purchaseFlight(flightId, userId);

This is finally failing, as we’re expecting. By introducing the Brand generic type, we can tell TypeScript that although our values are both strings, they are indeed of different types.

Notice how we had to use the as operator when declaring our values. Without that, TypeScript would have no way to know that "U123" is not a simple string but a UserId.

There are different ways to implement Brand, each with pros and cons. Some TypeScript libraries like Effect come with one. I like to use Matt Pocock’s implementation because it’s easy to include in any codebase.

declare const brand: unique symbol;
type Brand<T, TBrand extends string> = T & { [brand]: TBrand };

You can play with Brand and the example in this post in this playground.